package org.codefilarete.stalactite.engine.runtime.query;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.codefilarete.reflection.AccessorByMethodReference;
import org.codefilarete.reflection.AccessorDefinition;
import org.codefilarete.reflection.PropertyAccessPoint;
import org.codefilarete.reflection.PropertyMutator;
import org.codefilarete.reflection.ReadWriteAccessPoint;
import org.codefilarete.reflection.ReadWritePropertyAccessPoint;
import org.codefilarete.reflection.ValueAccessPoint;
import org.codefilarete.reflection.ValueAccessPointChain;
import org.codefilarete.stalactite.dsl.MappingConfigurationException;
import org.codefilarete.stalactite.engine.configurer.DefaultComposedIdentifierAssembler;
import org.codefilarete.stalactite.engine.configurer.builder.BuildLifeCycleListener;
import org.codefilarete.stalactite.engine.configurer.builder.PersisterBuilderContext;
import org.codefilarete.stalactite.engine.configurer.elementcollection.ElementRecordMapping;
import org.codefilarete.stalactite.engine.configurer.map.KeyValueRecordMapping;
import org.codefilarete.stalactite.engine.runtime.load.AbstractJoinNode;
import org.codefilarete.stalactite.engine.runtime.load.EntityInflater;
import org.codefilarete.stalactite.engine.runtime.load.EntityJoinTree;
import org.codefilarete.stalactite.engine.runtime.load.JoinNode;
import org.codefilarete.stalactite.engine.runtime.load.JoinRoot;
import org.codefilarete.stalactite.engine.runtime.load.RelationJoinNode;
import org.codefilarete.stalactite.engine.runtime.load.TablePerClassRootJoinNode;
import org.codefilarete.stalactite.engine.runtime.query.EntityCriteriaSupport.AccessorToColumnMap;
import org.codefilarete.stalactite.mapping.AccessorWrapperIdAccessor;
import org.codefilarete.stalactite.mapping.EntityMapping;
import org.codefilarete.stalactite.mapping.id.assembly.IdentifierAssembler;
import org.codefilarete.stalactite.mapping.id.assembly.SingleIdentifierAssembler;
import org.codefilarete.stalactite.query.api.JoinLink;
import org.codefilarete.stalactite.query.api.QueryStatement.PseudoTable;
import org.codefilarete.stalactite.query.api.Selectable;
import org.codefilarete.stalactite.sql.ddl.structure.Column;
import org.codefilarete.tool.Reflections;
import org.codefilarete.tool.StringAppender;
import org.codefilarete.tool.VisibleForTesting;
import org.codefilarete.tool.collection.Arrays;

import static org.codefilarete.stalactite.engine.configurer.elementcollection.ElementRecord.ELEMENT_ACCESSOR;
import static org.codefilarete.stalactite.engine.configurer.map.KeyValueRecord.VALUE_ACCESSOR;

/**
 * Maps the aggregate property accessors points to their column: relations are taken into account, that's the main benefit of it.
 * Thus, anyone can get the column behind an accessor chain.
 * The mapping is collected through the {@link EntityJoinTree} by looking for {@link RelationJoinNode} and their {@link EntityMapping}
 * (actually the algorithm is more complex).
 */
public class AggregateAccessPointToColumnMapping<C> {
	
	/**
	 * Owned properties mapping
	 * The implementation is based on {@link AccessorDefinition} comparison as keys. This can be a quite heavy comparison but we have no other
	 * choice : propertyToColumn is built from entity mapping which can contains
	 * - {@link ReadWriteAccessPoint} (for exemple) whereas getColumn() will get
	 * - {@link AccessorByMethodReference} which are quite different but should be compared.
	 */
	private final Map<List<? extends ValueAccessPoint<?>>, JoinLink<?, ?>> propertyToColumn = new AccessorToColumnMap();
	
	private final EntityJoinTree<C, ?> tree;
	
	@VisibleForTesting
	AggregateAccessPointToColumnMapping(EntityJoinTree<C, ?> tree, boolean withImmediatePropertiesCollect) {
		this.tree = tree;
		
		if (withImmediatePropertiesCollect) {
			collectPropertiesMapping();
		} else {
			PersisterBuilderContext.CURRENT.get().addBuildLifeCycleListener(new BuildLifeCycleListener() {
				@Override
				public void afterBuild() {
					collectPropertiesMapping();
				}
				
				@Override
				public void afterAllBuild() {
					
				}
			});
		}
	}
	
	public boolean hasCollectionProperty() {
		return propertyToColumn.keySet().stream().flatMap(Collection::stream)
				.anyMatch(valueAccessPoint -> Iterable.class.isAssignableFrom(AccessorDefinition.giveDefinition(valueAccessPoint).getMemberType()));
	}
	
	@VisibleForTesting
	public Map<List<? extends ValueAccessPoint<?>>, JoinLink<?, ?>> getPropertyToColumn() {
		return propertyToColumn;
	}
	
	private void collectPropertiesMapping() {
		Deque<RelationJoinNode<?, ?, ?, ?, ?>> accessorPath = new ArrayDeque<>();
		Map<List<PropertyAccessPoint<?, ?>>, Selectable<?>> rootProperties = collectPropertiesMapping(tree.getRoot(), new ArrayList<>());
		rootProperties.forEach((valueAccessPoints, selectable) -> {
			this.propertyToColumn.put(valueAccessPoints, tree.getRoot().getOriginalColumnsToLocalOnes().get(selectable));
		});
		Queue<AbstractJoinNode<?, ?, ?, ?>> stack = Collections.asLifoQueue(new ArrayDeque<>());
		stack.addAll(tree.getRoot().getJoins());
		while (!stack.isEmpty()) {
			AbstractJoinNode<?, ?, ?, ?> abstractJoinNode = stack.poll();
			if (abstractJoinNode instanceof RelationJoinNode) {
				RelationJoinNode<?, ?, ?, ?, ?> relationJoinNode = (RelationJoinNode<?, ?, ?, ?, ?>) abstractJoinNode;
				accessorPath.add(relationJoinNode);
				Map<List<PropertyAccessPoint<?, ?>>, Selectable<?>> joinNodeProperties = collectPropertiesMapping(relationJoinNode, accessorPath.stream().map(RelationJoinNode::getPropertyAccessor).collect(Collectors.toSet()));
				joinNodeProperties.forEach((valueAccessPoints, selectable) -> {
					this.propertyToColumn.put(valueAccessPoints, relationJoinNode.getOriginalColumnsToLocalOnes().get(selectable));
				});
				if (abstractJoinNode.getJoins().isEmpty()) {
					// no more joins, this is a leaf, we remove the whole branch
					do {
						accessorPath.removeLast();
					} while ((accessorPath.peekLast() != null ? accessorPath.peekLast().getJoins().size() : 0) == 1);
				}
			}
			stack.addAll(abstractJoinNode.getJoins());
		}
	}
	
	/**
	 * Collect properties, read-only properties, and identifier column mapping from given {@link EntityMapping}
	 *
	 * @param joinNode the node from which we can get a {@link EntityMapping} to collect all properties from
	 */
	private <E> Map<List<PropertyAccessPoint<?, ?>>, Selectable<?>> collectPropertiesMapping(JoinNode<E, ?> joinNode, Collection<PropertyAccessPoint<?, ?>> accessorPath) {
		EntityInflater<E, ?> entityInflater;
		if (joinNode instanceof JoinRoot) {
			if (joinNode instanceof TablePerClassRootJoinNode) {
				entityInflater = ((TablePerClassRootJoinNode<E, ?>) joinNode).getEntityInflater();
				EntityMapping<E, ?, ?> entityMapping = entityInflater.getEntityMapping();
				PseudoTable pseudoTable = ((TablePerClassRootJoinNode<E, ?>) joinNode).getTable();
				// Because we have a Union (behind the pseudo table), there's no mapping with "original ones" (we are in table-per-class)
				// so we provide a small column "adapter" that lookup for the columns in the union
				return collectPropertiesMapping(entityMapping, accessorPath, col -> pseudoTable.findColumn(col.getExpression()));
			} else {
				// non-polymorphic root, aka simple case
				entityInflater = ((JoinRoot<E, ?, ?>) joinNode).getEntityInflater();
			}
		} else if (joinNode instanceof RelationJoinNode) {
			entityInflater = ((RelationJoinNode<E, ?, ?, ?, ?>) joinNode).getEntityInflater();
			
			// Note about complex type handling: The goal of all this code is to suit the need of Spring Data and its derived queries. There are few
			// reasons (for now) to adhere to any other framework or API (meanwhile we are open to do it, but for now, that's the status of the
			// project), thus, because Spring-Data doesn't support complex type (in particular for Maps), we don't also.
			// see Spring discussion about Collection and Map here https://github.com/spring-projects/spring-data-commons/issues/2504
			EntityMapping<E, ?, ?> entityMapping = entityInflater.getEntityMapping();
			if (entityMapping instanceof ElementRecordMapping) {    // Collection mapping case
				// Querying Collection is only possible on its values (obviously !)
				Map<List<PropertyAccessPoint<?, ?>>, Selectable<?>> result = new HashMap<>();
				Column<?, ?> mapValueColumn = entityMapping.getPropertyToColumn().get(ELEMENT_ACCESSOR);
				List<PropertyAccessPoint<?, ?>> accessorPrefix = new ArrayList<>(accessorPath);
				result.put(accessorPrefix, mapValueColumn);
				return result;
			} else if (entityMapping instanceof KeyValueRecordMapping) {    // Map mapping case
				// Querying Map is only possible on its values
				Map<List<PropertyAccessPoint<?, ?>>, Selectable<?>> result = new HashMap<>();
				Column<?, ?> mapValueColumn = entityMapping.getPropertyToColumn().get(VALUE_ACCESSOR);
				List<PropertyAccessPoint<?, ?>> accessorPrefix = new ArrayList<>(accessorPath);
				result.put(accessorPrefix, mapValueColumn);
				return result;
			}
		} else {
			// this should not happen because we master the node types that support getEntityInflater !
			throw new UnsupportedOperationException("Unsupported join type " + Reflections.toString(joinNode.getClass()));
		}
		EntityMapping<E, ?, ?> entityMapping = entityInflater.getEntityMapping();
		return collectPropertiesMapping(entityMapping, accessorPath, Function.identity());
	}
	
	private <E> Map<List<PropertyAccessPoint<?, ?>>, Selectable<?>> collectPropertiesMapping(EntityMapping<E, ?, ?> entityMapping,
																							 Collection<PropertyAccessPoint<?, ?>> nodeAccessorPath,
																							 Function<Selectable<?>, Selectable<?>> columnAdapter) {
		Map<List<PropertyAccessPoint<?, ?>>, Selectable<?>> result = new HashMap<>();
		
		// Collecting basic direct properties
		Stream.concat(entityMapping.getPropertyToColumn().entrySet().stream(), entityMapping.getReadonlyPropertyToColumn().entrySet().stream())
				.forEach((entry) -> {
					PropertyMutator<E, ?> accessor = entry.getKey();
					List<PropertyAccessPoint<?, ?>> key;
					if (accessor instanceof ValueAccessPointChain) {
						// - we can expect ValueAccessPointChain accessors to be of PropertyAccessPoint type (because the ORM
						// doesn't support persistence for indexed value like ListAccessor or ArrayAccessor)
						// - we make a copy of the List because it will be modified some line below to add the accessors prefix
						key = new ArrayList<>((List<? extends PropertyAccessPoint<?, ?>>) ((ValueAccessPointChain) accessor).getAccessors());
					} else {
						key = Arrays.asList(accessor);
					}
					result.put(key, columnAdapter.apply(entry.getValue()));
				});
		
		// collecting the identifier mapping (because they are not in the properties mapping) 
		IdentifierAssembler<?, ?> identifierAssembler = entityMapping.getIdMapping().getIdentifierAssembler();
		if (identifierAssembler instanceof SingleIdentifierAssembler) {
			Column idColumn = ((SingleIdentifierAssembler) identifierAssembler).getColumn();
			ReadWritePropertyAccessPoint idAccessor = ((AccessorWrapperIdAccessor) entityMapping.getIdMapping().getIdAccessor()).getIdAccessor();
			result.put(Arrays.asList(idAccessor), columnAdapter.apply(idColumn));
		} else if (identifierAssembler instanceof DefaultComposedIdentifierAssembler) {
			((DefaultComposedIdentifierAssembler<?, ?>) identifierAssembler).getMapping().forEach((idAccessor, idColumn) -> {
				ReadWritePropertyAccessPoint accessorPrefix = ((AccessorWrapperIdAccessor) entityMapping.getIdMapping().getIdAccessor()).getIdAccessor();
				result.put(Arrays.asList(accessorPrefix, idAccessor), columnAdapter.apply(idColumn));
			});
		}
		
		// going deeper in embeddable properties to collect them
		entityMapping.getEmbeddedBeanStrategies().forEach((k, v) ->
				v.getPropertyToColumn().forEach((p, c) -> {
					result.put(Arrays.asList(k), columnAdapter.apply(c));
				})
		);
		
		// adding the readWriteAccessPoint prefix to all the found properties, to create an readWriteAccessPoint available from the aggregate root
		result.forEach((k, v) -> {
			k.addAll(0, nodeAccessorPath);
		});
		return result;
	}
	
	/**
	 * Gives the column of an access points {@link List}.
	 * Most of the time result is a 1-size collection, but in the case of complex type (as for a composite key), the result is a collection of columns.
	 * Note that it supports "many" accessor: given parameter acts as a "transport" of accessors, we don't use
	 * its functionality such as get() or toMutator() hence it's not necessary that it be consistent. For example,
	 * it can start with a Collection accessor then an accessor to the component of the Collection. See
	 *
	 * @param valueAccessPoints chain of accessors to a property that has a matching column
	 * @return the found column, throws an exception if not found
	 */
	public JoinLink<?, ?> giveColumn(List<? extends ValueAccessPoint<?>> valueAccessPoints) {
		// looking among current properties
		JoinLink<?, ?> column = this.propertyToColumn.get(valueAccessPoints);
		if (column != null) {
			return column;
		} else {
			throw newConfigurationException(valueAccessPoints);
		}
	}
	
	private MappingConfigurationException newConfigurationException(List<? extends ValueAccessPoint<?>> valueAccessPoints) {
		StringAppender accessPointAsString = new StringAppender() {
			@Override
			public StringAppender cat(Object o) {
				if (o instanceof ValueAccessPoint) {
					super.cat(AccessorDefinition.toString((ValueAccessPoint) o));
				} else {
					super.cat(o);
				}
				return this;
			}
		};
		accessPointAsString.ccat(valueAccessPoints, " > ");
		return new MappingConfigurationException("Error while looking for column of " + accessPointAsString
				+ " : it is not declared in mapping of " + Reflections.toString(this.tree.getRoot().getEntityInflater().getEntityType()));
	}
}
